feat(studio): GSAP runtime read layer + shared helpers#1607
feat(studio): GSAP runtime read layer + shared helpers#1607miguel-heygen wants to merge 2 commits into
Conversation
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at f87ce080e74fa1ff9712e7fac91728bc1a7aad7c (Group 2 of the stable-keyframes stack — sibling to #1608). Read in context of #1555 (source-mutation API consumer) and the bridge in #1608.
This is the read-layer half of the studio-runtime pair. Three independent failure modes — multi-tween playhead selection, array-form keyframes, cold-parse retry — each fixed at the right layer with tests that read real exports. Clean PR.
Findings
-
gsapRuntimeKeyframes.ts:178(playhead-aware tween pick) — thefirstReadfallback when the playhead is outside every range is the right semantic ("element still has motion, just nothing under the cursor"). Race-fix classification per the race-fix preempt-vs-narrow rubric: this is PREEMPT for the "second gesture stuck on the first tween" symptom (per-element playhead-range walk instead ofObject.keys(...)[0]). Good. -
gsapRuntimeKeyframes.ts:200(zero-duration skip inaddScanEntry) — symmetric withreadRuntimeKeyframesskip at line 188. Sibling-asymmetry as evidence rubric: matches. The duplication is fine for now but the predicate (!(duration > 0)) appears 3x in this file — a tinyisHoldOrZeroSet(tween)helper would make intent more grep-able and harden against a future fourth call site forgetting it. Nit. -
gsapShared.ts:118(array-form keyframes) —i/(n-1)*100correctly guardsn==1with the ternary. One subtle behavior worth flagging: if an array entry is{ease: "power1"}-only (ease stripped, no other props), it's silently skipped — butistill advances, so the percentages of the surviving entries follow the original index spacing (not redistributed). That's the right call for GSAP semantics (ease is per-segment, not a keyframe) but please verify against a realkeyframes: [{x:0}, {ease:"none"}, {x:100}]source — does GSAP itself accept that shape, and if so does the studio source-mutation API round-trip it? If not, dead code path; if yes, the dropped middle ease is silently lost. -
useGsapAnimationFetchFallback.ts:30(cold-parse retry) —COLD_PARSE_RETRIES=5 * COLD_PARSE_DELAY_MS=120= up to 600ms blocking the drag-commit chain before fallback fires. For an initial-load race this is fine, but if the parse endpoint is genuinely down (5xx, network out), every drag eats 600ms before the CSS path runs. Consider a separate fast-fail onfetchreject vs cold-but-warming parse — fetch rejection means "endpoint dead, don't retry,"parsed.animations.length === 0means "warm but empty, retry." Right nowfetchParsedAnimationsreturnsnullon both —selectElementAnimationsOrRetrycan't tell them apart and retries either way. Concern. -
useGsapTweenCache.ts:115(isStudioHoldSetfilter) — clean. One question: doesisStudioHoldSetfrom@hyperframes/core/gsap-parsermatchdata: "hf-hold"exactly, or also catch other studio-internal markers? If the sentinel string changes in the parser, this filter silently goes stale. Sibling test ingsapRuntimeKeyframes.test.tsusesdata: "hf-hold"literally — keeping the sentinel in one place (@hyperframes/core/gsap-parserconst export) would prevent drift. Verify both ends import the same constant. -
useGsapAnimationFetchFallback.test.ts— coverage is good for the three branches ofselectElementAnimationsOrRetry, but the actual retry loop (5x 120ms with the integration shape ofuseGsapAnimationFetchFallback) isn't tested. The retry timing + early-return onattempt >= COLD_PARSE_RETRIESis the load-bearing addition. Not a blocker — pure helper is tested, retry shape is mechanically simple — but a fake-timers test of the loop would lock it in.
What I didn't verify
- The
gsap.set/gsap.getPropertyinteraction in #1608'sapplyStudioPathOffsetViaGsap— covered there. - Live behavior under real preview iframe with
__timelinespopulated by the producer — the fake-iframe tests cover the read path mechanically. - That
isStudioHoldSetandparsePercentageKeyframes's array branch handle the same shape the source-mutation API in #1555 emits (cross-stack).
LGTM modulo the isStudioHoldSet cross-import check and the cold-parse fetch-vs-warm-but-empty distinction. Stamp-routable to Rames Jusso after those are confirmed.
— Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
Reviewed at f87ce080. Concur with @james-russo-rames-d-jusso — clean read-layer slice, the three independent fixes (multi-tween playhead disambiguation, array-form keyframes, cold-parse retry) each land at the right layer with tests reading real exports.
Verified at HEAD:
- Zero-duration set hold-set shadowing:
gsapRuntimeKeyframes.ts:188-192and:245-247both skipduration <= 0tweens. Comments call out thedata:"hf-hold"case explicitly. Test coverage atgsapRuntimeKeyframes.test.tslines 24-55 hits both "read past the hold" and "return null when ONLY a hold exists" paths. - Multi-tween playhead disambiguation: captures
firstReadbefore any range check, prefers in-range, falls back tofirstRead. Three-case test coverage matches behavior. PREEMPT-class race fix. - Array-form keyframe parsing in
gsapShared.parsePercentageKeyframescovered for object form, GSAP array form as evenly-distributed steps, null for no-positional-props. useGsapAnimationFetchFallbackcold-parse retry:null(cold, retry) vs[](warm, no match, don't retry) — the distinction IS the explicit design, not masking. Max 5 × 120ms = 600ms ceiling. Documented + tested.tlIdnon-timeline marker skip at:165-167: when no explicit composition id, picks the first key whose value hasgetChildren, skipping markers like__proxied. Reasonable.
Concur with @james-russo-rames-d-jusso on:
gsapShared.ts:118array-form ease-only entries —i/(n-1)*100advancesieven when the entry is silently skipped, so percentages of surviving entries follow original index spacing not redistributed. Right call for GSAP semantics (ease is per-segment, not a keyframe) but worth verifying against a realkeyframes: [{x:0}, {ease:"none"}, {x:100}]source round-trips correctly through the studio source-mutation API.useGsapAnimationFetchFallback.ts:30cold-parse retry conflatesfetch-reject vs warm-but-empty — both returnnull, both trigger retry. If the parse endpoint is genuinely down, every drag eats 600ms before CSS fallback fires. Fast-fail onfetchreject + retry only on cold-but-warming would tighten this. NIT-level but real.useGsapTweenCache.ts:115isStudioHoldSetfilter — confirm the sentinel string (data:"hf-hold") is exported from@hyperframes/core/gsap-parseras a single const, not duplicated string-literal across both sides. Drift here would silently filter wrong tweens.
LGTM. No band-aid patterns, no dispatch-chain gaps, no spurious console additions. Clean slice.
Review by Via
…, isZeroDurationSet, array-ease tests - useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once. - Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent. - parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior ease is stripped without shifting the other keyframes' percentages.
b79f553 to
1612463
Compare
f87ce08 to
e28b305
Compare
|
Subsumed by #1605 (retargeted to main with the full stack) |
* chore(producer): shim __filename/__dirname in the CJS banner Bundled CJS deps like wawoff2 call __dirname; without the shim they throw "__dirname is not defined in ES module" at render time. Also ignore .zed/. * chore(producer): use a template literal for the CJS banner (review nit) * feat(core): add GSAP keyframe + motion-path source mutations Array-form keyframe removal in both the recast and acorn writers, plus update/add/remove-motion-path-point and add-motion-path. Exclude _auto and data from tween property-group classification. * fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks - Regression test for the `data` GSAP-key exclusion (parallel to _auto). - splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests. - addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated. - Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug — documented as it.skip with a ready assertion (acorn cutover follow-up). * feat(core): route motion-path mutations through studio-api + fix clip stamping Wire the new mutations into the file save route. Only authored clips suppress descendant stamping, so auto-stamped animated scenes can inline-expand. Hide in-flow timed clips with `display:none` only when they are LEAF clips (no nested timed clips). `display:none` on a container removes its whole subtree, hiding descendants that are still inside their own visibility window — e.g. an in-flow composition root whose effective window clamps to the timeline end would black out a child video that should still show (the hdr-hlg regression). Containers keep `visibility:hidden`, which a visible descendant can override; only leaves leave the flow, which is all the split-overlap case needs. * feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule A position or rotation add/set mutation makes the GSAP timeline the single source of truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both channels, and the add-strip fires for the position AND rotation property groups. Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio cannot save drag/resize edits to elements in a registered timeline — the exact premise the single-source work inverts (the timeline is now the edit target). Removed the rule, its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests. * fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel - HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta, shift-positions, scale-positions, split-animations. (add stays out: flat tweens only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.) - init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change; visible/hidden branches both go through isTimedClipInFlow (was .get() by accident). - keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set strips the stale --hf-studio-rotation channel. * feat(studio): GSAP runtime read layer + shared helpers * fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests - useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once. - Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent. - parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior ease is stripped without shifting the other keyframes' percentages. * feat(studio): GSAP drag/commit/bridge editing infra * fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the server save, fixing both consumers (useEnableKeyframes + useGestureCommit's showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation return type widened void→Promise<void> (fire-and-forget consumers ignore it). - stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live). - commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek, so a failed commit leaves the dropped pose, not a cleared element. * feat(studio): motion-path geometry + commit helpers * docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics * feat(studio): on-canvas motion-path overlay * fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold * feat(studio): keyframes flag, gesture recording + timeline/selection refinements * fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens - useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's empty parse isn't overridden by a stale selectedGsapAnimations cache. - useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick). - useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent). - playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV). - useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per group, so a mixed gesture no longer yields an untagged legacy tween. * feat(studio): single-source manual offset + rotation via the GSAP timeline Dragging or rotating an element writes into the GSAP timeline (the single source of truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and the live preview moves via gsap.set so what you see equals what is written and renders. Removes the dual-channel CSS-var/transform reconciliation behind the fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/ keyframe commit, dropped the handleDom*Commit CSS fallbacks). Subcompositions edit the same single-source way, which surfaced and fixes: - resolve a subcomp element's source file via the composition-id map (the runtime drops the source linkage when inlining the subcomposition); - a selected element's selection box AND motion path use basic visibility, not the occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover); - soft reload rebuilds ONLY the committed composition's timeline, leaving other compositions' timelines intact (no cross-composition revert); - read keyframes from the element's OWN composition timeline (scan all timelines, not the first unstable key); - delete-all uses a soft reload too, so editing no longer hard-reloads the iframe. * fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures - DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only position/rotation channel; the false branch silently killed drag+rotate (and let GSAP elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests. - gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script. - gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops partial reads (no phantom identity) and re-applies the drag override in finally. - MotionPathOverlay: park-timer cleanup keyed on animId change. - useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review). * feat(studio): patchRuntimeTweenInPlace — update a tween's values in place Defensive runtime helper: locate the element's tween in window.__timelines via the shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek the playhead — without re-running the whole composition. Returns false (caller falls back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant, flicker-free manual edits. * fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches - resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props. - patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch], instead of silently dropping the computed expression. * feat(studio): instantPatch fast path in runCommit A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on success the preview updates in place with NO reload (instant), on false it falls back to the existing soft reload. Extracts the preview-sync tail into a testable applyPreviewSync helper. No behavior change when instantPatch is absent. * feat(studio): route static position/rotation set drags through instantPatch Static-element position and rotation set commits now attach instantPatch{selector, change:{kind:set}} so the drag updates in place with no reload. Structural ops (new tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit it and keep the soft reload — keyframe instant-patch needs object-form keyframe support in patchRuntimeTweenInPlace (deferred). * fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure - commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated values flow into the patch; can't drift). - Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y}, so a second-POST failure still leaves the preview patched for what persisted. - applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload); per U4 the synchronous false still does NOT escalate. - (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.) * feat(studio): no full iframe remount for soft-reloadable edits A softReload edit (and the SDK single-script refresh) no longer escalates to a full reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set already shows the value, and a remount is the worst flash + re-inlines subcomps (reverting their keyframes). verifyTimelinesPopulated now checks the expected target keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty. Full reload stays only for the structural (no-softReload) and ambiguous-script paths. * feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the timeline. So when a user adds a motion path to a composition that didn't originally use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN async path (the flash). Idempotent + defensive; the existing async fallback stays for genuine cold-start/CDN-failure. * fix(studio): don't re-save + reload when source editor syncs externally The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged — including the programmatic dispatch that syncs external content (e.g. a manual-edit commit writing the source back into the open editor). That made the editor re-save the file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe edit — defeating the in-place instant patch and causing the flash. Annotate the programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save. * fix(core): inject MotionPathPlugin into preview when a composition uses motionPath A studio-created motion path writes a gsap motionPath tween into the single-source timeline, but the preview HTML only loaded gsap core — so the first render threw "Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject MotionPathPlugin right after the composition's gsap script, version-matched to it. * fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact) * fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' | 'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script, or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome (origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted. - SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused). - Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append). * fix(core): align __clipTree and __clipManifest ids via stableClipId Timeline inline expansion was dead for nested children inside index.html: the tree keyed id-less elements by a synthetic __clip-N while the manifest keyed them null, so parent<->child never joined. Both now resolve identity through stableClipId (id || data-hf-id), which every generated element has. * fix(core): strip baked runtime + tag comp root in preview assembly Comps that ship a baked inline runtime were double-loaded (preview injects its own) and the baked copy failed to parse inline (Unexpected token '<'). Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also tag the comp root with data-composition-file so the studio resolves a comp's top-level elements to the right source file instead of defaulting to index.html (which made the GSAP panel parse the wrong, multi-timeline file). * feat(studio): set motion-path destination from a toolbar toggle Replaces the double-click-on-canvas UX (which painted text over the preview) with a 'Set motion destination' toggle next to Snap/Grid, shown only when the selected element can take a path. While armed, one canvas press places the destination. Also removes the dead TimelinePropertyRows component. * fix(studio): center timeline keyframe diamonds on their percentage Dropped clampDiamondLeft, which forced boundary keyframes fully inside the clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's midpoint now sits exactly on its % (the clip is overflow-visible). * fix(studio): resize static elements via tl.set, not a single-stop keyframes tween Resizing an element with no size animation wrote keyframes:{ <playhead%>: {width,height} } — one mid-point stop GSAP can't interpolate, so it rendered NaN/0 dimensions at every other frame and the element vanished (worst off 0%). Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize now writes tl.set({width,height}), held at all frames; re-resizing updates it in place. * fix(studio): negative-cache failed media probes Only successful probes were cached, so CORS/404 cross-origin media was re-probed every rAF-driven timeline re-derive, flooding the console. Remember failed URLs and skip them. * fix(studio): type window.setTimeout handle as number ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is present and clashes with the DOM number the call returns. Type it number. * fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps - Fix soft-reload clearProps destroying element inline styles — save cssText, clear, restore, strip only transform - Fix resize no-op on re-resize: delete+add instead of two update-property - Route set tweens through static resize path (convertToKeyframes skips sets) - Re-fetch animation ID before drag commit to prevent stale-ID duplicates - Guard editDebugLog for Node test environments - Fix NLELayout setState-during-render (move reset to useEffect) - Stop SnapToolbar pointer events propagating to canvas deselect handler - Enable click-to-add waypoints on cubic motion paths - Add whole-path drag offset (Alt+drag shifts all keyframes together) - Add Canvas shortcuts section to ShortcutsPanel - Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance) - Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports)

Summary
This PR establishes the GSAP runtime read layer for the Studio editor — the code that reads back live keyframed motion from the preview iframe's running GSAP timelines and reconciles it with the parsed source. It hardens three read paths (live tween reads, percentage-keyframe parsing, and the parse-fetch fallback) against the failure modes that surface once an element can carry multiple gesture-recorded tweens, array-form keyframes, and studio-emitted hold
sets. It also introduces small, tested helper functions so the read logic is unit-testable in isolation.What's changed
packages/studio/src/hooks/gsapRuntimeKeyframes.tsreadRuntimeKeyframesnow resolves the timeline robustly and picks the right tween under the playhead:window.__timelines(e.g. the studio's__proxiedflag) by selecting the first key whose value actually has agetChildrenfunction, instead of blindly takingObject.keys(...)[0].time()and, when an element has more than one keyframed tween at disjoint time ranges (two non-overlapping gesture recordings → twoto()s), returns the tween whose[start, start+duration]range contains the playhead (with a1e-3epsilon). If the playhead is outside every range (or the timeline exposes no clock), it falls back to the first keyframed tween held infirstRead.readRuntimeKeyframesandaddScanEntrynow skip zero-duration tweens (tl.set(...), including the studio position-holddata:"hf-hold"). These sit before the real keyframed tween and otherwise shadow it —readTweenwould fall back to a degenerate 2-point flat path from the set's values, hiding the actual multi-keyframe motion.RuntimeTimelinegains an optionaltime?: () => number;ReadTweenis now exported (for the test + downstream consumers).packages/studio/src/hooks/gsapShared.tsparsePercentageKeyframesnow handles GSAP array-form keyframes (keyframes: [{x,y}, {x,y}, ...]) in addition to the object/percentage form. Array steps are evenly distributed: stepiofnmaps toi/(n-1)*100%(rounded to one decimal), numeric props rounded to 3 decimals,easeskipped. Previously array-keyframed tweens (e.g. a multi-point shuttle path) parsed asnull→ no motion path drawn.getIframeDocumenthelper.packages/studio/src/hooks/useGsapAnimationFetchFallback.tsselectElementAnimationsOrRetry(parsed, target): returnsnullto signal a retry only when the parse is cold (missing or zero total animations — the initial-load race), returns the matched animations on a warm parse, and returns[](no retry) when a warm parse has no match for the element (it genuinely has no animation).useGsapAnimationFetchFallbackcallback now retries the async parse fetch (COLD_PARSE_RETRIES = 5,COLD_PARSE_DELAY_MS = 120) on a cold parse instead of falling straight through to the no-animation path. This closes the race where a drag fires before the parse is warm and the empty result causes the tween to be duplicated rather than edited.packages/studio/src/hooks/useGsapTweenCache.tsfetchParsedAnimationsnow filters studio-emitted pre-keyframe holdsets out of the parsed animations viaisStudioHoldSet(from@hyperframes/core/gsap-parser). These holds are an internal runtime detail (they hold an element's first keyframe before its tween) and must not surface as user animations, where they would pollute the keyframe cache and timeline diamonds.Why
Once the editor lets a user record multiple gestures, edit array-form keyframes, and relies on studio-emitted hold
sets, the naive "read the first matching tween" logic breaks in several concrete ways: recording a second gesture leaves the motion path stuck on the first; a zero-duration holdsetshadows the real keyframed tween and collapses the path to a flat 2-point line; array-form keyframes don't parse at all; a drag that fires before the async parse is warm duplicates the tween instead of editing it; and internal holdsets leak into the user-facing animation list. This PR makes the runtime read layer correct and playhead-aware, so the overlay always reflects the actual motion under the cursor.Testing
Three vitest files, all reading the real exported helpers:
gsapRuntimeKeyframes.test.ts— adds afakeIframebuilder that stubs a runtime timeline (getChildren,duration, optionaltime) and selector resolution. Covers: a zero-duration hold-set must not shadow the keyframed tween (reads all 4 keyframes; returnsnullwhen the element has only a zero-duration set); multiple tweens / playhead selection (inside the second reads the second, inside the first reads the first, outside every range falls back to the first).gsapShared.test.ts—parsePercentageKeyframes: parses the object/percentage form; parses array-form keyframes as evenly-distributed steps ([0, 33.3, 66.7, 100], properties preserved); returnsnullfor empty array and empty object.useGsapAnimationFetchFallback.test.ts—selectElementAnimationsOrRetry: returnsnull(retry) for a cold parse; returns the matching animations from a warm parse; returns[](no retry) for a warm parse with no match.Stack
Part of the GSAP keyframe/motion-path stack, split into reviewable PRs:
#1553 → #1554 → #1555 → #1607 → #1608 → #1609 → #1610 → #1611 → #1567 → #1612 → #1613 → #1605. This is the bottom studio PR (on #1555). Each PR builds independently; the combined diff of the split is byte-identical to the originally-reviewed work.